/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

import type {AbsolutePath, RepoRelativePath} from 'isl/src/types';
import type {Repository} from './Repository';
import type {RepositoryContext} from './serverTypes';

import {GeneratedStatus} from 'isl/src/types';
import {promises as fs} from 'node:fs';
import pathMod from 'node:path';
import {LRU} from 'shared/LRU';
import {group} from 'shared/utils';
import {Internal} from './Internal';

export const GENERATED_TAG = '@' + 'generated';
export const PARTIALLY_GENERATED_TAG = '@' + 'partially-generated';

const defaultGeneratedPathRegex =
  Internal.generatedFilesRegex ??
  /(yarn\.lock$|package-lock\.json$|node_modules\/.*$|\.py[odc]$|\.class$|\.[oa]$|\.so$|Gemfile\.lock$|go\.sum$|Cargo\.lock$|\.dll$|\.exe$|\.pdb$|composer\.lock$|Podfile\.lock$)/;

async function getGeneratedFilePathRegex(
  repo: Repository,
  ctx: RepositoryContext,
): Promise<RegExp> {
  const configuredPathRegex = await repo.getConfig(ctx, 'isl.generated-files-regex');
  let regex = defaultGeneratedPathRegex;
  if (configuredPathRegex) {
    try {
      regex = new RegExp(configuredPathRegex);
    } catch (err) {
      repo.initialConnectionContext.logger.error(
        'Configured generated files regex is invalid',
        err,
      );
    }
  }
  return regex;
}

function readFilesLookingForGeneratedTag(
  cwd: AbsolutePath,
  files: Array<string>,
): Promise<Array<[RepoRelativePath, GeneratedStatus]>> {
  return Promise.all(
    files.map(async (path): Promise<[RepoRelativePath, GeneratedStatus]> => {
      let chunk;
      try {
        const f = await fs.open(pathMod.join(cwd, path));
        chunk = await f.read({length: 1024});
        f.close();
      } catch (e) {
        // e.g. missing files considered Manual. This can happen when queries files in non-head commits.
        // More accurate would be to `sl cat`, but that's expensive.
        return [path, GeneratedStatus.Manual];
      }
      if (chunk.buffer.includes(GENERATED_TAG)) {
        return [path, GeneratedStatus.Generated];
      } else if (chunk.buffer.includes(PARTIALLY_GENERATED_TAG)) {
        return [path, GeneratedStatus.PartiallyGenerated];
      }
      return [path, GeneratedStatus.Manual];
    }),
  );
}

export class GeneratedFilesDetector {
  // We assume the same file path doesn't switch generated status, so we can cache aggressively.
  private cache = new LRU<RepoRelativePath, GeneratedStatus>(1500);

  /**
   * Given a list of files, return an object mapping path to Generated Status.
   * Files are determined to be generated by looking in the first 512 bytes for @ + generated,
   * or partially generated by looking for @ + partially-generated.
   */
  public async queryFilesGenerated(
    repo: Repository,
    ctx: RepositoryContext,
    root: AbsolutePath,
    files: Array<RepoRelativePath>,
  ): Promise<Record<RepoRelativePath, GeneratedStatus>> {
    if (files.length === 0) {
      return {};
    }
    const t1 = performance.now();
    const {logger} = ctx;

    const regex = await getGeneratedFilePathRegex(repo, ctx);

    const results = group(
      files,
      // (1) try the cache
      // (2) test if it matches the generated file regex, if so, it's generated
      // (3) if the regex fails or its not in cache, we need to try reading the file
      file =>
        this.cache.get(file) ??
        (regex.test(file) ? GeneratedStatus.Generated : undefined) ??
        'notCached',
    );

    const needsCheck = results.notCached ?? [];

    const checkResult =
      needsCheck.length === 0 ? [] : await readFilesLookingForGeneratedTag(root, needsCheck);

    const remaining = new Set(needsCheck);
    for (const [path, st] of checkResult) {
      this.cache.set(path, st);
      remaining.delete(path);
    }

    const t2 = performance.now();
    logger.info(
      `Generated file query took ${Math.floor((10 * (t2 - t1)) / 10)}ms for ${
        files.length
      } files. (${needsCheck.length} not cached)`,
    );

    return Object.fromEntries([
      ...(results[GeneratedStatus.Manual]?.map(p => [p, GeneratedStatus.Manual]) ?? []),
      ...(results[GeneratedStatus.Generated]?.map(p => [p, GeneratedStatus.Generated]) ?? []),
      ...(results[GeneratedStatus.PartiallyGenerated]?.map(p => [
        p,
        GeneratedStatus.PartiallyGenerated,
      ]) ?? []),
      ...checkResult,
    ]);
  }

  public clear() {
    this.cache.clear();
  }
}

export const generatedFilesDetector = new GeneratedFilesDetector();
